iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI Engineering

從單一agent 到多agent 集群的開發流水帳以及應用系列 第 20

Day 16|從「已停止」到真的停下來:補齊 Mac 與 iOS 的生命週期

  • 分享至 

  • xImage
  •  

系列:「一人艦隊:30 天用一群 AI CLI 打造跨平台代理 mesh」— 第 16 天

Day 15,我把 Mac App 從縮窗、切頁、關閉到重新打開跑了一遍。畫面能回來,任務能找回來,但最後留下了一個很不舒服的結果:按了停止,任務被標成「已取消」,資料庫裡卻仍然存著完整輸出。

當時測了 500 行與 800 行的長回覆,最後都完整生成。紀錄裡的約 45 秒與 91 秒是整個任務耗時,不能拿來當作「按停止後又等了多久」。光是這個差別,就提醒我不能只看狀態標籤判定功能完成。

Day 15 草稿保存之後,開發還在繼續。今天先把漏掉的那段補齊,再接著處理真正的停止。

一、終端機有畫面之後,還得能退出

Mac App 裡的終端機最初是一片白。這次補的是一整條連線:App 取得自己管理的 runtime 實際 port,讓內嵌頁面能在限定的 loopback 來源載入,再連到同一份 App 所附的 CLI。

這裡最容易混淆的是「畫面關掉」與「程序結束」。終端機背後有 PTY,也有子程序;如果使用者離開畫面後,它們仍留在背景,表面上正常的 App 其實已經累積了孤兒程序。

因此,我把驗證分成幾段:

  • 原生終端機能執行 /help,貼上 /agents,縮窗後仍能使用。
  • 中斷連線後,該次建立的 PTY 程序消失。
  • 退出 App 後,GUI、它管理的 runtime 與 PTY 都結束。
  • 子程序以指定的退出碼結束時,WebSocket 收到相同的退出碼。

最後一項真的抓到了競態。同樣讓子程序以 7 結束,修正前連跑三次收到 7、7、null。問題出在讀取輸出遇到 EOF 與等待程序退出之間的先後順序:輸出已讀完,不代表退出狀態已經被收妥。

修正後,新的候選包透過真實 WebSocket 連跑三次,都收到 7

這裡還有一個紀錄原則:前一個候選包有原生視窗截圖,後一個候選包有退出碼測試,兩者不能合併成「最新版全部通過」。後來 Mac 鎖定,最新版的原生 Ctrl-C 與重連畫面就保留待測。

二、iOS 第一次跑起來,不等於手機功能完成

接著把同一套核心放進 iOS 模擬器。這一步先撞到的是平台邊界:桌面專用的 PTY、runtime 管理與隱藏視窗 API,被帶進了行動端建置。

我把這些入口補上平台條件。行動端不支援的終端功能,回傳明確的不可用結果;桌面的關窗收進背景,也不直接套用到手機。

修正後,在專用的 iPhone 13 mini/iOS 26.5 模擬器上完成建置、安裝、啟動、退出與重新啟動,並檢查身分檔案仍是同一份、權限維持 0600,資料庫也存在。後續設定修正又更新了候選包並重跑啟動流程。其中一次 Rust 編譯已成功,卻在打包重新命名時因舊產物目錄非空而失敗;保留舊包、移開衝突目錄後,下一次才完成。建置最後一步失敗,也必須算那次建置失敗。

這些結果證明模擬器產物能裝、能開,身分檔在重開後仍保留,資料庫檔案存在。它還不能證明實體 iPhone 已能獨立工作。模擬器的 loopback 會看到 Mac 服務,這次因此略過了內嵌 HTTP listener;log 也仍有 mDNS 通道錯誤。實體手機沒有連線,就繼續列為未驗證。

手機對話頁也補了一個比較不顯眼的問題:歷史載入中、讀取失敗或資料格式錯誤時,不能把畫面當成「沒有歷史」直接開始新對話。現在會保留載入與錯誤狀態、提供重試;清除失敗時,也保留原本對話。這一包有 9 個前端測試通過,但 Provider/Cluster 保存與手機停止仍有缺口。

三、按下保存,下一個任務真的會用新設定嗎?

首次設定看起來像表單,其實連著三個不同狀態:畫面上的選擇、磁碟裡的設定,以及 runtime 當下正在使用的設定。

只讓「下一步」按鈕亮起來,很容易漏掉後兩個。

前端先補上保存失敗不能前進、回傳格式不正確不能當成功,以及等待保存時不能重複提交或返回。這組測試從 4 個失敗、11 個成功,修到 15 個全部通過。這仍然是模擬 IPC 的前端測試,原生首次設定畫面還需要另外操作。

核心則補上設定版本。假設任務 A 已經開始,使用的是設定 A;使用者這時保存設定 B,合理的行為是:

任務 A 開始 ── 取得設定 A ─────────── 持續使用 A ── 結束
                         │
                    保存並啟用 B
                         │
新任務 B 開始 ── 取得設定 B ───────── 使用 B

正在執行的工作保留同一份設定快照,新工作才切換到新版。連同 provider 的失敗狀態也以版本隔離,避免舊工作污染新設定。

接著把 iOS 首次設定接到 runtime 實際使用的資料根目錄。保存流程先讀取並合併既有內容,保留其他設定、指令與工具,再寫入私有暫存目錄、驗證、同步、原子替換;成功之後才發布新的執行設定。測試也抓到原本會把既有區網 Ollama 位址改回 localhost 的問題,這次一併修正。

這一段最後有 10 個核心測試通過,包含保存失敗保留舊設定、真正的重新命名失敗、金鑰跳脫、區網位址保留,以及本機 HTTP 驗證:既有 runtime 的新任務和從已保存檔案重建的 runtime,都命中新 provider。

仍然不能跳過的邊界是:從設定檔重建 AgentRuntime,不等於完整 App 重開流程。加密設定模式的正式啟動載入路徑,以及 Mac 外部 runtime 的首次設定套用,仍需接續處理。

四、停止要中斷等待,不能只在收尾改標籤

回到 Day 15 留下的問題。原本非串流請求在送出 HTTP 後,會等待完整回覆;取消旗標主要在回合邊界檢查。因此使用者按停止時,畫面已記住要求,底層卻可能繼續等到整份內容回來。

另一個問題在收尾:如果任務已完成、停止訊號才抵達,程式仍可能因為看到旗標而把完整成功結果改標為取消。

這次先修共用的非串流 HTTP 路徑,並用本機測試伺服器固定三個等待點;Gemini 原生、OAuth 與 CLI 的專用路徑尚未涵蓋即時取消:

場景 測試伺服器做什麼 取消後應有的結果
等待回應標頭 收到完整請求,但先不回應 runtime 不必等伺服器放行就能結束等待
等待回應內容 回傳標頭與部分內容,剩餘內容停住 能結束 body 讀取
限流重試 回傳 429 與重試間隔 不必等倒數完、不再重試或切換 provider

三個測試都先等伺服器確認請求已抵達,再發出停止,避免用「睡一下應該到了」來碰運氣。修正前,三個測試全部失敗:500 毫秒內無法結束,還在等伺服器放行。

修正後,這三個場景都能在測試設定的 500 毫秒內結束本機等待。再加上完成後才收到停止、工具派送前收到停止兩個案例,核心這組共 5 個測試通過。工具測試讓停止發生在審核關卡,確認兩個原本要寫檔的工具都沒有產生檔案。

接著把執行器的回傳值加上明確的終止原因:CompletedCancelled。寫入資料庫時依照這個原因保存,不再回頭看停止旗標猜測。

持久化測試又抓到一次真實失敗:先取得本機 HTTP 的完整回答,再於寫入結果前透過真正的 stop_chat 路由發出停止,舊邏輯把結果存成 cancelled。修正後仍是 completed,同時保留「曾收到停止要求」的紀錄。另一個案例則確認 HTTP 尚未放行時已取消,重開資料庫後仍能讀到同一份結果,重送相同任務 ID 也不會重新執行。

這兩個案例加上既有 Local App 測試,共 9 個通過;設定版本與保存的 10 個回歸測試也通過。程式碼經兩份非作者審查確認後,新的 Mac 候選包也完成了建置與簽章驗證。

最後,再用新包附帶的 CLI 啟動真正的 runtime,搭配獨立資料目錄與本機 HTTP 測試伺服器。這次從發出停止到保存取消結果約 25 毫秒;runtime 退出、重啟後,同一份取消結果仍在,重送相同 ID 也沒有再次呼叫 provider。另一個完整完成的任務,在事後收到停止時仍保持完成。

這是單次本機 fixture 的結果,不是對所有模型的延遲保證,也還不是操作原生停止按鈕的驗收。後續嘗試連接新候選的原生視窗時,操作工具遇到副本識別與逾時問題;雖然程序與測試 runtime 已核對正確,仍沒有完成按鈕操作,因此這一項繼續保留待驗。第一次執行這份 smoke script 時,路徑比對還因 macOS 的符號連結與 canonical 路徑表示不同而失敗;統一比對實際路徑後才通過。這個失敗屬於測試腳本,不算產品取消邏輯的另一個 RED。

HTTP 等待可以取消,也不代表遠端模型一定停止生成或停止計費。這次能驗證的是本機不再繼續等、不再派送後續工作,以及最後保存正確的終止原因。CLI 子程序則需要自己的退出與清理程序,不能因為 HTTP 的作法可行,就直接把整個 CLI future 丟掉。

五、手機上的「停止接收」,也不能叫作「取消工作」

Mac 的核心等待修正後,我又回頭看手機版的停止按鈕。它原本只是移除接收事件的監聽器,卻把空回覆改成「已中斷」,也立即開放下一個問題。這會造成兩個誤解:後端可能還在工作,而上一個工作的遲到結果可能改掉下一個問題的畫面。

這一輪先把能力說清楚。本機與 Cluster 路徑還沒有取消完成的確認機制,就停用停止按鈕、顯示「此模式尚未支援停止,請等待結果」,直到該次請求真的返回才解除等待。Provider 直連可以關閉接收,但按鈕改叫「停止接收」,並明示後端可能仍在執行。

同時替每次請求記住自己的版本。Provider 直連模式在停止接收或離開畫面後,會忽略舊請求的 token、完成與錯誤;晚到的監聽 handle 也會立即解除。本機全域 token 事件的跨掛載隔離,仍待補上 request ID。工作尚未完成時,也不能切換模式。

新增 6 個元件測試先全部失敗,加上既有 9 個測試,修正後共 15 個通過。相關前端回歸共 147 個測試、16 個檔案通過,TypeScript 與網頁建置也成功。接著將修補打入新的 iOS 模擬器候選,完成建置、更新安裝與程序重開,確認身分金鑰保留且權限仍為 0600;也實際走過首次設定,進入聊天畫面。這些證據只涵蓋安裝、啟動與畫面入口,尚未完成原生停止操作或實體 iPhone 驗收。全域 token 事件缺少 request ID、Provider/Cluster 的持久保存也仍待處理。停用不可靠的按鈕只是誠實呈現現況,並不代表手機已支援真正取消。

六、把「通過了什麼」留在同一份紀錄裡

Day 15 留下的是可恢復的任務狀態;Day 16 繼續補程序退出、設定生效與取消等待。這些都屬於使用者不一定看得到,卻會直接影響可信度的行為。

目前共同驗收表列了 80 個案例,包括共用生命週期、Mac 終端、iOS 與畫面入口。80 是清單數量,不是通過數量;沒操作的標成未測,缺設備的標成受阻,已知失敗也保留。

下一輪會在這個新的 Mac 候選包上操作原生按鈕,重跑停止、退出與重開;iOS 則繼續補 Provider/Cluster 保存、停止語意,以及加密設定的啟動讀取。每一份截圖、測試與產物版本對得起來,這些紀錄才能成為下一次開發的起點。


上一篇
Day 15|從縮窗到重開:把 Mac App 的任務生命週期跑一遍
下一篇
Day 17|中斷五天之後:把 9/10 到 9/14 的紀錄補回來,手機真的成了節點
系列文
從單一agent 到多agent 集群的開發流水帳以及應用21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言